Data classes, i.e. Java classes whose sole purpose is to hold data and make it accessible via getters and setters, are among the largest collection points of boilerplate code in many software projects. To create constructors for each new class, the methods equals, hashCode and toString and for each field, a getter and a setter has become a hated ceremony for many developers – unless they directly use libraries like Lombok to avoid it. JEP 359 should help.
With JEP 359, records are introduced into the JVM – although for the time being only as a preview feature. In order to try it out, both the compiler and the created program must be executed with the -enable-preview flag.
STAY TUNED!
Learn more about JAX London
The problem to be solved with Records
What exactly makes creating data classes in Java so tedious? The core of such classes is the defined list of instance variables that represent the state description of an object. If developers want to design a class that reflects a cube, they can get by with three variables: height, width, and depth. To work with instances of the type cube, however, they must fulfill further formalities: The class requires constructors, getters, and setters. The methods equals, hashCode, and toString must also be overwritten. In most cases, all of this work runs so uniformly that developers have it done automatically by their development environment: Getter and setter are created for each instance variable. All of them should be considered in hashCode, equals, and toString (or worse: hashCode and equals are not implemented at all). Often a constructor is also defined, which can contain all variables as parameters to create a fully initialized object. After this procedure, the class then has just under 65 lines of code, of which about five are sufficient to describe the most important information – the name, the modifier, and what state it holds.
Of course, the surface, edge length, or volume of a cube can also be interesting – but these values are derived from its state description and do not represent new variables. To get the volume of the cube, developers provide a calculateVolume() method, which multiplies the length, height, and width of the cube. These methods also contain information that is important for all developers on the team to understand the cube’s properties. Identifying them among the other nine methods generated earlier by the development environment may be difficult at first glance. The class mixes the “what” with the “how”; it has an unnecessarily high cognitive complexity. The introduction of records is intended to avoid exactly this problem by also modeling data as data and accessing it from the context.
Structure and structure of records
Records are a new type declaration in Java and represent a specialized form of a class. They are intended for a clearly defined purpose but have limitations compared to conventional classes, similar to Enums. The state description of the record is made by defining so-called components, which consist of a type and a name. A simple record describing a cube with the components “height”, “width”, and “depth” is given below:
1
2 3 |
public record Cube(int height, int width, int depth) {
}
|
Listing 1 shows a comparable conventional class.
Listing 1
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
public final class Cube{
private final int height; private final int width; private final int depth;
public Cube(int height, int width, int depth) { this.height = height; this.width = width; this.depth = depth; }
public int getHeight() { return height; }
public int getWidth() { return width; }
public int getDepth() { return depth; }
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Cube cube= (Cube) o; return height == cube.height && width == cube.width && depth == cube.depth; }
@Override public int hashCode() { return Objects.hash(height, width, depth); }
@Override public String toString() { return “Cube{” + “height=” + height + “, width=” + width + “, depth=” + depth + ‘}’; } } |
It is noticeable here that the components of the record are part of its declaration and are not defined in the body of the class. Each of these components corresponds to an implicitly defined, final instance variable, and an access method of the same name, which can be defined either implicitly or explicitly. All components are also part of the standard constructor. The “how”, that is, the way in which the state of an object can be accessed, is therefore derived from its definition and does not have to be written down explicitly. The following code can therefore be used to create an instance of the records cube shown above and access the value of the height:
1
2 3 |
var cube = new cube(10, 10, 10); cube.height(); > 10 |
Records do not have setters because their instance variables are implicitly final, as described above, and therefore cannot be updated after instantiation. Furthermore, the methods equals, hashCode, and toString do not have to be implemented for records – they also result from the components. If necessary, they can be overwritten and thus adapted.
1
2 3 4 5 6 7 |
cube.toString(); > Cube[height=10, width=10, depth=10]
var w1 = new Cube(10, 10, 10); var w2 = new Cube(10, 10, 10); w1.equals(w2); > true |
From the fact that the access methods have the same name as their components, a problem follows: Some component names would overwrite methods that belong to the class Object, for example, getClass, finalize, or even toString. For this reason, the following names are forbidden for records and lead to a compile error: clone, finalize, getClass, hashCode, notify, notifyAll, readObjectNoData, readResolve, serialPersistentFields, serialVersionUID, toString, wait, writeReplace.
Further attributes of records
Records can be supplemented with static class variables, for example, to name literals. However, you cannot declare additional instance variables, since these would belong to the state of an object. However, this state should only be defined using the components of a record. Additional methods, for example, to perform calculations with the state of an object, are permitted (Listing 2). Static methods and static initialization blocks are also possible.
Listing 2
1
2 3 4 5 6 7 8 9 10 11 |
public record Cube (int height, int width, int depth) { private final static int DENSITY= 3;
public int volume() { return height * width * depth; }
public int mass() { return volume() * DENSITY; } } |
Constructors
The state of an object is defined via the constructor when it is created and cannot be changed afterward – at least on the defined level. In the text to the JEP, this state is called “shallowly immutable”. Since the access methods of components provide references to the objects they contain, it is possible to manipulate the objects to which they refer. Actually immutable records are not possible – similar to normal classes the references to encapsulated values can escape.
With larger records, which have more than ten components, the legibility of code can suffer. While the context of the data can still be recognized by the method names when calling setters, this is much less obvious with large parameter lists in constructors, for example in Listing 3. The representation may be exaggerated by using literals instead of speaking variable names, but the problem of difficult readability nevertheless exists. This problem is mitigated by the support of development environments (for example, IntelliJ’s Early Acess Preview shows the variable name before each constructor parameter), but does not work when viewing the code in a diff view. Here, named parameters would help, as they are used, for example, by Kotlin. Brian Goetz already played with this idea in an article that deals with the possibilities of data classes and “sealed types”.
Alternatively, this problem could be circumvented with a builder approach, as Lombok already provides for data classes. A library written for this purpose by the author of this article can be found on GitHub.
Listing 3
1
2 3 4 5 6 7 8 9 10 |
var game = new Game(new Person(“Rüdiger”, “Behrens”, LocalDate.of(1982, 3, 17), 186), new Person(“Frank”, “Meier”, LocalDate.of(1990, 2, 2), 170), 10, 20); |
Unlike normal classes, the standard constructor for records is not parameterless, but contains parameters for all components of the record. Since each record instance cannot be changed after it has been created, the constructor is useful for checking whether the object has been completely and coherently initialized. In Listing 4 you can see that constructors in Records can be defined in exactly the same way as in common classes. Unfortunately, this leads to the elegant, concise state description of the record being repeated several times. Each variable name is typed four more times: in the parameter list of the constructor, in the validation, and twice when assigning it to the instance variable. Since this runs counter to the desired simplicity of records, a shorthand notation for constructors of records was introduced, which is listed in Listing 5. It requires neither a parameter list nor the assignment of parameters to the instance variables – both are done implicitly. Developers can concentrate exclusively on the validation logic, i.e. again on the “what”, not on the “how”.
Listing 4
1
2 3 4 5 6 7 8 9 10 11 12 |
public record Cube(int height, int width, int depth) { public Cube(int height, int width, int depth) {
if(height < 0 || width < 0 || depth < 0) { throw new IllegalArgumentException(“Values must not be negative!”); }
this.height = height; this.width = width; this.depth = depth; } } |
Listing 5
1
2 3 4 5 6 7 8 9 |
public record Cube (int height, int width, int depth) {
public Cube { if (height < 0 || width < 0 || depth < 0) { throw new IllegalArgumentException(“Values must not be negative!”); } }
} |
Inheritance
Records are implicitly final, so no other class can inherit from them. If we look at the grammar of records in Listing 6, we notice something else: Records can implement one or more interfaces, but they cannot inherit from a superclass or another class. The code section in Listing 7 shows a record that implements an interface. These inheritance rules are again derived from the fact that records should be defined by their state description. Common Java classes, on the other hand, can define and manipulate their state as they like. If a record were to inherit from such a class, this fact could no longer be guaranteed. Inheriting from another record would also dilute the clarity of the declaration. A record would inherit further components that would be part of its state description. This would mean that the same state description would be spread over several types and would be more difficult to capture.
Listing 6
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
RecordDeclaration: {ClassModifier} record TypeIdentifier [TypeParameters] (RecordComponents) [SuperInterfaces] [RecordBody]
RecordComponents: {RecordComponent {, RecordComponent}}
RecordComponent: {Annotation} UnannType Identifier
RecordBody: { {RecordBodyDeclaration} }
RecordBodyDeclaration: ClassBodyDeclaration RecordConstructorDeclaration
RecordConstructorDeclaration: {Annotation} {ConstructorModifier} [TypeParameters] SimpleTypeName [Throws] ConstructorBody |
Listing 7
1
2 3 4 5 6 7 8 9 10 11 12 |
public interface Body { int volume(); }
public record Cube (int height, int width, int depth) implements Body {
@Override public int volumen() { return height * width * depth; }
} |
Annotations and Reflection
Both records themselves and their components can be annotated. The same rules apply to annotations at type level as for classes: The annotation must allow TYPE as a target. You can use annotations on the components of a record that allow PARAMETER, FIELD, METHOD, or the new target RECORD_COMPONENT introduced with Records. The large number of allowed annotation targets results from the implicitly derived elements of a record component: constructor parameters, instance variables, and methods.
The Java Reflection API has also been enhanced to enable you to work with records. The type java.lang.Class has been extended by two methods: isRecord() can be used to determine whether a class is a record. The method getRecordComponents() returns an array of the new type java.lang.reflect.RecordComponent, which contains information about the components of a record, such as the name, data types, generic types, annotations, or the access method.
Summary and outlook
With the introduction of records, Java gets an interesting new type – even if, for the time being, only as a preview feature. The plan to model data briefly and concisely as data and free it from the usual ceremony is well on its way and is eagerly awaited by many developers. Not only does the generation of always uniform code become obsolete and the danger of forgetting the important methods equals and hashcode during implementation is eliminated. Instead, the readability of code can be greatly simplified in the future, so that the important attributes of a type can be captured more quickly. It will be interesting to see how common libraries deal with the unchangeable character of record instances. Currently, many mappers and serialization tools use the setters of data classes to fill them with values. Here, a change to a constructor-based approach is expected in order to be able to handle records as well.
The limitations of the new type will ensure that records will by no means replace the traditional variable data types in all situations. This is also explicitly not intended. These restrictions were decided for good reason and with an eye towards the future: Due to the (by humans and compilers) easy to grasp state description of records, they are, in combination with Sealed Types (JEP 360), very well suited for pattern matching and destructuring and thus for another big step towards further functional features in Java.
It remains to be seen which changes to the concepts described here will still occur in the preview phase. Experience with previous preview features shows that feedback from the community is generally taken into account and assumptions made can be checked again. Nevertheless, it can already be seen that records will be a great enrichment for Java and will open the door to further innovations.